VPC Lambdaのコールドスタートにお悩みの方へ捧ぐコールドスタート予防のハック Lambdaを定期実行するならメモリの割り当ては1600Mがオススメ?!
2019/5/31追記
先日のBlack BeltでLambdaの内部構造について一部解説がありました。
※スライド公開され次第更新します
これまでWorkerというコンポーネントはMicroVMを指しているという理解だったのですが、実際にはMicroVMをホストするEC2インスタンスのレイヤーがWorkerに相当するようです。以後の「Worker」という表記は基本的に「MicroVM」に置き換えて読んで頂くようお願いします。
はじめに
サーバーレス開発部@大阪の岩田です。
先日のブログでLambdaのコールドスタートの裏側について考察しました。
これまでの常識は間違っていた?!Lambdaのコールドスタート対策にはメモリ割り当てを減らすという選択肢が有効に働く場面も
本ブログでは前回の考察をベースにLambdaのコールドスタート回避に関するテクニックについて深掘りしていきます。なお、以後の記述は全てコールドスタートの影響が分かりやすいVPC Lambdaを前提とします。
以後記載している内容はあくまでも私の主観に基づく考察です。実際の仕様とは異なる可能性があることをあらかじめご了承ください。
VPC Lambdaのコールドスタート回避テクニックについて
Lambdaのコールドスタートを回避するテクニックとして、定期的にLambda関数を実行することでコンテナの破棄を避けるテクニックが知られています。
この方法は、常に定期的にLambda関数を実行することで、以下のような環境を維持していると考えられます。
この状態で新たにLambda関数実行のリクエストがあった場合、アクセス数があまり多くなければ
- プロビジョニング済みのサンドボックス環境で処理を行う
- ENIアタッチ済みのWorker上に新たにサンドボックス環境を構築して処理を行う
のいずれかになり、Lambda関数実行の遅延を最小限に抑えられるはずです。しかし、多数のLambda関数実行リクエストが発生すると、ENIアタッチ済みのWorker内に十分な数のサンドボックス環境を準備できず、新たにWorkerの割り当てとENIの作成&アタッチ処理が発生すると考えられます。この辺りの動作について検証していきます。
検証環境の構築
VPC Lambda & API Gatewayの作成
まずLambda関数のコードを用意しておきます。正直コードはどうでも良いです。
def handler(event, context): return { 'statusCode': 200, 'body': 'hello' }
このコードをSAMテンプレートのCodeUri
から参照してAPI Gatewayのバックエンドとしてデプロイします。
SAMテンプレートです。
API GWのパス/test
と/ping1
配下にメモリ割り当て128MのLambda関数を、/ping2
~/ping6
配下にメモリ割り当て1600MのLambda関数を作成します。
AWSTemplateFormatVersion: 2010-09-09 Transform: AWS::Serverless-2016-10-31 Description: for blog Globals: Function: Runtime: python3.7 Handler: index.handler CodeUri: ./ MemorySize: 1600 VpcConfig: SecurityGroupIds: - <適当なセキュリティグループのID> SubnetIds: - <適当なサブネットのID> Resources: LambdaExecuteRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - 'lambda.amazonaws.com' Action: sts:AssumeRole ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaENIManagementAccess Test: Type: AWS::Serverless::Function Properties: Role: !GetAtt LambdaExecuteRole.Arn MemorySize: 128 Events: GetResource: Type: Api Properties: Path: /test Method: get Ping1: Type: AWS::Serverless::Function Properties: Role: !GetAtt LambdaExecuteRole.Arn MemorySize: 128 Events: GetResource: Type: Api Properties: Path: /ping1 Method: get Ping2: Type: AWS::Serverless::Function Properties: Role: !GetAtt LambdaExecuteRole.Arn Events: GetResource: Type: Api Properties: Path: /ping2 Method: get Ping3: Type: AWS::Serverless::Function Properties: Role: !GetAtt LambdaExecuteRole.Arn Events: GetResource: Type: Api Properties: Path: /ping3 Method: get Ping4: Type: AWS::Serverless::Function Properties: Role: !GetAtt LambdaExecuteRole.Arn Events: GetResource: Type: Api Properties: Path: /ping4 Method: get Ping5: Type: AWS::Serverless::Function Properties: Role: !GetAtt LambdaExecuteRole.Arn Events: GetResource: Type: Api Properties: Path: /ping5 Method: get Ping6: Type: AWS::Serverless::Function Properties: Role: !GetAtt LambdaExecuteRole.Arn Events: GetResource: Type: Api Properties: Path: /ping6 Method: get
メモリ割り当て1600MのLambda関数は以下のような環境を作成することを目的としています。
AWSのドキュメントによると
Lambda 関数で VPC にアクセスする場合は、Lambda 関数でのスケーリング要件をサポートできる充分な ENI キャパシティーが VPC にあることを確認します。次の式を使用すると、ENI 要件を概算できます Projected peak concurrent executions * (Memory in GB / 3GB)
次のとおりです。
- Projected peak concurrent execution (投射されたピーク時の同時実行) – この値を決定するには、「同時実行数の管理」の情報を使用します。
- メモリ – Lambda 関数用に設定したメモリの容量。
VPC 対応の Lambda 関数をセットアップするためのガイドライン
とのことなので、メモリ1600MのLambda関数を作成すれば、Workerを1つ確保しつつ、1424M分のメモリを余剰リソースとしてプールしておけるのではないか?という推測です。
テスト用クライアント
次にテスト用クライアントの環境を構築します。 今回利用した環境は以下の通りです。
- OS: Amazon Linux2(ami-00d101850e971728d) m5.large
- テスト用ツール: hey v0.1.2
上記のAMIを指定したEC2を立ち上げ、heyをインストールします
sudo amazon-linux-extras install golang1.11 go get -u github.com/rakyll/hey
インストールできたらbash_profileに以下の記述を追加して、パスを通します。
PATH=$PATH:$HOME/go/bin
準備ができたら早速検証していきます。
検証実施
テストコマンド
以後の検証では、全て以下のコマンドを利用しています。
hey -n 500 -c 50 https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/Stage/test
API Gatewayに対して50並列で合計500リクエストを発行し、/test
というパスにぶら下がるメモリ128MのVPC Lambdaを起動します。50並列で実行しているのでLambdaのサンドボックス環境1つでは処理できず、サンドボックス環境がスケールアウトしていくはずです。
検証1.特に小細工無し
まずは特に小細工無しで普通にテストを流してみます。事前にVPC Lambdaから利用可能なENIが存在しないことを確認しておきます。
EC2に紐付くENIしか存在しない状況です。 この状態でテストを実行すると確実にコールドスタートが発生するはずです。 また、Lambdaのメモリ割り当ては128Mかつテストは50並列で流すので、計算式としては 128M * 50クライアント / 3G ≒ 2.08となり、ENIが3つ必要になる想定です。
実行結果です。
Summary: Total: 13.4614 secs Slowest: 13.0460 secs Fastest: 0.0194 secs Average: 1.2528 secs Requests/sec: 37.1433 Total data: 2500 bytes Size/request: 5 bytes Response time histogram: 0.019 [1] | 1.322 [448] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 2.625 [1] | 3.927 [0] | 5.230 [0] | 6.533 [0] | 7.835 [0] | 9.138 [0] | 10.441 [4] | 11.743 [9] |■ 13.046 [37] |■■■ Latency distribution: 10% in 0.0329 secs 25% in 0.0388 secs 50% in 0.0454 secs 75% in 0.0613 secs 90% in 10.1785 secs 95% in 12.2642 secs 99% in 12.8836 secs Details (average, fastest, slowest): DNS+dialup: 0.0087 secs, 0.0194 secs, 13.0460 secs DNS-lookup: 0.0051 secs, 0.0000 secs, 0.0520 secs req write: 0.0000 secs, 0.0000 secs, 0.0009 secs resp wait: 1.2440 secs, 0.0193 secs, 12.9546 secs resp read: 0.0000 secs, 0.0000 secs, 0.0007 secs Status code distribution: [200] 500 responses
10秒超えのリクエストが合計50回発生しています。ENIの状況を確認してみましょう。
Lambda用にENIが2つ生成されていることが分かります。ENIは3つ作成されると見積もりましたが、50並列の負荷掛けクライアントが完全に同時にアクセスする訳では無いので、まあ妥当な線だと思います。
検証2.メモリ128MのLambda関数を暖機しておく
次にLambdaを定期実行することでコールドスタートを抑制しつつ、再度テストしてみます。 以下のPythonのスクリプトを実行し、無限ループの中で2分に1回VPC Lambdaを起動します。 ※5分に1回程度のペースが推奨されていましたが、確実にWarmサンドボックスをキープしたかったので、実行間隔を短くしています。
import requests from time import sleep API_URL_BASE = 'https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/Stage/' def main(): while True: requests.get(f'{API_URL_BASE}ping1') sleep(120) if __name__ == '__main__': main()
メモリを128M割り当てたLambdaを定期実行しているので、Lambda実行環境は以下のような状態をキープしている想定です。
ENIの状況です。
LambaにアタッチされたENIは1つなので、意図通りになっていそうです。この状態でテストを流すと以下のような結果になりました。
Summary: Total: 13.4155 secs Slowest: 13.0072 secs Fastest: 0.0185 secs Average: 0.8371 secs Requests/sec: 37.2702 Total data: 2500 bytes Size/request: 5 bytes Response time histogram: 0.018 [1] | 1.317 [446] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 2.616 [13] |■ 3.915 [12] |■ 5.214 [0] | 6.513 [0] | 7.812 [0] | 9.111 [0] | 10.409 [2] | 11.708 [8] |■ 13.007 [18] |■■ Latency distribution: 10% in 0.0316 secs 25% in 0.0370 secs 50% in 0.0424 secs 75% in 0.0587 secs 90% in 1.8128 secs 95% in 10.6099 secs 99% in 12.9119 secs Details (average, fastest, slowest): DNS+dialup: 0.0086 secs, 0.0185 secs, 13.0072 secs DNS-lookup: 0.0050 secs, 0.0000 secs, 0.0504 secs req write: 0.0000 secs, 0.0000 secs, 0.0044 secs resp wait: 0.8285 secs, 0.0184 secs, 12.9303 secs resp read: 0.0000 secs, 0.0000 secs, 0.0001 secs Status code distribution: [200] 500 responses
10秒超えのリクエストは合計28回と、検証1に比べるとレスポンスが改善しています。ただし、28回のリクエストについてはENI作成の影響を受けて非常にレスポンスが遅くなっているようです。50並列同時に実行したことでWorker1つでプロビジョニング可能な数以上にサンドボックス環境が必要になり、新たにWorkerの割り当てとENI作成&アタッチ処理が実行されたと考えられます。ENIの状況を確認すると、検証1.実施後と同様Lambda用にENIが2つ作成された状態でした。
検証3.メモリ1600MのLambda関数を5つ暖機しておく
先ほどのping用のPythonスクリプトを修正し、メモリ割り当て1600MのLambda5つに対して定期的にリクエストを送ります。
requests.get(f'{API_URL_BASE}ping1')
の部分を
requests.get(f'{API_URL_BASE}ping2') requests.get(f'{API_URL_BASE}ping3') requests.get(f'{API_URL_BASE}ping4') requests.get(f'{API_URL_BASE}ping5') requests.get(f'{API_URL_BASE}ping6')
に変更して実行します。 Lambda実行環境は以下のような状態をキープしている想定です。
Worker5台分の空き領域を合計すると50並列ぐらいは確保済みのリソースで捌けそうですよね? こんな感じで割り当て済みのWorker内に全てのサンドボックス環境がプロビジョニングされる想定です。
ENIの状況です。
LambaにアタッチされたENIは5つなので、意図通りになっていそうです。この状態でテストを流すと以下のような結果になりました。
Summary: Total: 2.9398 secs Slowest: 2.4377 secs Fastest: 0.0206 secs Average: 0.1540 secs Requests/sec: 170.0795 Total data: 2500 bytes Size/request: 5 bytes Response time histogram: 0.021 [1] | 0.262 [466] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■ 0.504 [3] | 0.746 [2] | 0.987 [2] | 1.229 [1] | 1.471 [2] | 1.713 [4] | 1.954 [7] |■ 2.196 [4] | 2.438 [8] |■ Latency distribution: 10% in 0.0300 secs 25% in 0.0364 secs 50% in 0.0423 secs 75% in 0.0578 secs 90% in 0.1562 secs 95% in 1.3603 secs 99% in 2.2589 secs Details (average, fastest, slowest): DNS+dialup: 0.0084 secs, 0.0206 secs, 2.4377 secs DNS-lookup: 0.0050 secs, 0.0000 secs, 0.0503 secs req write: 0.0000 secs, 0.0000 secs, 0.0007 secs resp wait: 0.1456 secs, 0.0205 secs, 2.4377 secs resp read: 0.0000 secs, 0.0000 secs, 0.0003 secs Status code distribution: [200] 500 responses
一番遅いリクエストでも約2.4秒、10秒超えのリクエストは0件という結果でした。想定通りコールドスタートは発生しなかったと言えそうです!!
まとめ
今回試した検証パターンの範囲ではVPC Lambaのコールドスタート対策にはメモリを1600M割り当てたLambda関数を複数用意し、定期的に実行しておくのが効果的という結論になりました。
もしLambdaのバックエンドが考察の通りになっていれば、Workerを確保しつつ1G以上のメモリを遊ばせておくのはAWS側からすると傍迷惑なWorkerの使い方です。このまま調子に乗って定期実行するLambdaの数を増やしていくと、また違った挙動を見せてくれるのかもしれません。また色々なパターンを増やして検証してみたいと思います。